Micron Document
🎖️GitЯра🎖️

Commit b23acf6892a95447998f4e2f076b3b521a26e469


Parents : 5641c69
Author : simulationstation <32910678+simulationstation@users.noreply.github.com>
Signature : Signature validation error
Date : 2026-08-12T02:26:50-10:00
Committer : GitHub <noreply@github.com>
Date : 2026-08-12T12:26:50Z

fix(settings): refresh delayed remote public keys (#6638)

Changes
Diff

diff --git a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
index d1d0d1361e..bc5c5f9385 100644
--- a/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
+++ b/feature/settings/src/commonMain/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreen.kt
@@ -21,7 +21,6 @@ import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.HorizontalDivider
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -29,6 +28,7 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
+import androidx.compose.ui.platform.testTag
import androidx.compose.ui.unit.dp
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import okio.ByteString
@@ -86,15 +86,6 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
val securityConfig = state.radioConfig.security ?: Config.SecurityConfig()
val formState = rememberConfigState(initialValue = securityConfig)
- var publicKey by rememberSaveable { mutableStateOf(formState.value.public_key) }
- LaunchedEffect(formState.value.private_key) {
- if (formState.value.private_key != securityConfig.private_key) {
- publicKey = ByteString.EMPTY
- } else if (formState.value.private_key == securityConfig.private_key) {
- publicKey = securityConfig.public_key
- }
- }
-
var showKeyGenerationDialog by rememberSaveable { mutableStateOf(false) }
PrivateKeyRegenerateDialog(
showKeyGenerationDialog = showKeyGenerationDialog,
@@ -131,19 +122,10 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
}
item {
TitledCard(title = stringResource(Res.string.direct_message_key)) {
- EditBase64Preference(
- title = stringResource(Res.string.public_key),
- summary = stringResource(Res.string.config_security_public_key),
- value = publicKey,
+ SecurityPublicKeyPreference(
+ securityConfig = securityConfig,
+ formState = formState,
enabled = state.connected,
- readOnly = true,
- keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
- onValueChange = {
- if (it.size == 32) {
- formState.value = formState.value.copy(public_key = it)
- }
- },
- trailingIcon = { CopyIconButton(valueToCopy = formState.value.public_key.encodeToString()) },
)
HorizontalDivider()
EditBase64Preference(
@@ -247,6 +229,46 @@ fun SecurityConfigScreenCommon(viewModel: RadioConfigViewModel, onBack: () -> Un
}
}
+internal const val SECURITY_PUBLIC_KEY_COPY_TEST_TAG = "security_public_key_copy"
+
+/**
+ * Public keys come from the device. Editing the private key invalidates that derived value until the device responds
+ * with the matching key pair, so display and copy must use the same resolved value.
+ */
+internal fun resolvedPublicKey(securityConfig: Config.SecurityConfig, editedPrivateKey: ByteString): ByteString =
+ if (editedPrivateKey == securityConfig.private_key) securityConfig.public_key else ByteString.EMPTY
+
+@Composable
+internal fun SecurityPublicKeyPreference(
+ securityConfig: Config.SecurityConfig,
+ formState: ConfigState<Config.SecurityConfig>,
+ enabled: Boolean,
+ publicKeyCopyButton: @Composable (ByteString) -> Unit = { publicKey ->
+ CopyIconButton(
+ valueToCopy = publicKey.encodeToString(),
+ modifier = Modifier.testTag(SECURITY_PUBLIC_KEY_COPY_TEST_TAG),
+ )
+ },
+) {
+ val focusManager = LocalFocusManager.current
+ val publicKey = resolvedPublicKey(securityConfig, formState.value.private_key)
+
+ EditBase64Preference(
+ title = stringResource(Res.string.public_key),
+ summary = stringResource(Res.string.config_security_public_key),
+ value = publicKey,
+ enabled = enabled,
+ readOnly = true,
+ keyboardActions = KeyboardActions(onDone = { focusManager.clearFocus() }),
+ onValueChange = {
+ if (it.size == 32) {
+ formState.value = formState.value.copy(public_key = it)
+ }
+ },
+ trailingIcon = { publicKeyCopyButton(publicKey) },
+ )
+}
+
@Suppress("MagicNumber")
@Composable
fun PrivateKeyRegenerateDialog(

diff --git a/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt
new file mode 100644
index 0000000000..8258eddd63
--- /dev/null
+++ b/feature/settings/src/commonTest/kotlin/org/meshtastic/feature/settings/radio/component/SecurityConfigScreenTest.kt
@@ -0,0 +1,90 @@
+/*
+ * Copyright (c) 2026 Meshtastic LLC
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program. If not, see <https://www.gnu.org/licenses/>.
+ */
+package org.meshtastic.feature.settings.radio.component
+
+import androidx.compose.material3.IconButton
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.assertIsDisplayed
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+import androidx.compose.ui.test.performClick
+import androidx.compose.ui.test.v2.runComposeUiTest
+import okio.ByteString
+import okio.ByteString.Companion.toByteString
+import org.meshtastic.core.model.util.encodeToString
+import org.meshtastic.core.ui.theme.AppTheme
+import org.meshtastic.proto.Config
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+@OptIn(ExperimentalTestApi::class)
+class SecurityConfigScreenTest {
+ @Test
+ fun `delayed remote public key updates display and copy without recreating screen`() = runComposeUiTest {
+ var securityConfig by mutableStateOf(Config.SecurityConfig())
+ var renderedFormState: ConfigState<Config.SecurityConfig>? = null
+ var copiedPublicKey: ByteString? = null
+
+ setContent {
+ AppTheme {
+ val formState = rememberConfigState(securityConfig)
+ renderedFormState = formState
+ SecurityPublicKeyPreference(
+ securityConfig = securityConfig,
+ formState = formState,
+ enabled = true,
+ publicKeyCopyButton = { publicKey ->
+ IconButton(
+ modifier = Modifier.testTag(SECURITY_PUBLIC_KEY_COPY_TEST_TAG),
+ onClick = { copiedPublicKey = publicKey },
+ ) {}
+ },
+ )
+ }
+ }
+
+ val publicKey = ByteArray(32) { (it + 1).toByte() }.toByteString()
+ val encodedPublicKey = publicKey.encodeToString()
+ onNodeWithText(encodedPublicKey).assertDoesNotExist()
+
+ // Firmware 2.8 redacts the remote private key, so only the public key changes when the delayed response lands.
+ securityConfig = Config.SecurityConfig(public_key = publicKey)
+ waitForIdle()
+
+ onNodeWithText(encodedPublicKey).assertIsDisplayed()
+ onNodeWithTag(SECURITY_PUBLIC_KEY_COPY_TEST_TAG).performClick()
+ assertEquals(publicKey, copiedPublicKey)
+ runOnIdle { assertEquals(ByteString.EMPTY, checkNotNull(renderedFormState).value.private_key) }
+
+ // A local private-key edit invalidates the device-derived public key for both render and copy.
+ runOnIdle {
+ val formState = checkNotNull(renderedFormState)
+ formState.value = formState.value.copy(private_key = ByteArray(32) { 7 }.toByteString())
+ }
+ waitForIdle()
+
+ onNodeWithText(encodedPublicKey).assertDoesNotExist()
+ copiedPublicKey = null
+ onNodeWithTag(SECURITY_PUBLIC_KEY_COPY_TEST_TAG).performClick()
+ assertEquals(ByteString.EMPTY, copiedPublicKey)
+ }
+}

Served by rngit 1.5.0 - Generated in 0.05s